Back to Hideout
dev
Data Structures

数据结构---图论

2025-05-10
数据结构 ------ 图.md
TOP
SECRET

数据结构 ------ 图

一、图的定义

节点和边组成图,边可以有权值,也可以有方向,分为有向图和无向图。

1、无向图

无向图是指边没有规定方向的图,也可以认为是双向的。

边上的值代表边的权重,节点上的值代表节点的权重。

例如下面的无向图:

graph LR
A((V1)) ---|5| B((V2))
A((V1)) ---|2| D((V4))
B((V2)) ---|7| E((V5))
D((V4)) ---|4| C((V3))
C((V3)) ---|3| B((V2))
C((V3)) ---|5| E((V5))

2、有向图

有向图是指边有方向的图,是单向的。

例如下方的有向图:

graph LR
A((V1)) --->|5| B((V2))
A((V1)) --->|2| D((V4))
B((V2)) --->|7| E((V5))
D((V4)) --->|4| C((V3))
C((V3)) --->|3| B((V2))
C((V3)) --->|5| E((V5))

二、图的存储

1、邻接矩阵

先举例有向图,根据上面的有向图,可以得出下面的矩阵:

[527354]\begin{bmatrix} \infty & 5 & \infty & 2 & \infty \\ \infty & \infty & \infty & \infty & 7 \\ \infty & 3 & \infty & \infty & 5 \\ \infty & \infty & 4 & \infty & \infty \\ \infty & \infty & \infty & \infty & \infty \end{bmatrix}

直接观察我们可以发现:

V1和V2之间存在值为5的边,那么在原矩阵中的第1行、第2列的值为5。

V1和V3之间没有直接的边,那么在矩阵中第1行、第3列的值为无穷。

第1行、第1列是无穷,说明节点到自己没有边也为无穷。

无向图同理,不过由于无向图的边是双向的,也就是说,在写V1到V2时,还得写V2到V1的边,因此可以得到以下矩阵:

[0502050307030452040007500]\begin{bmatrix} 0 & 5 & 0 & 2 & 0 \\ 5 & 0 & 3 & 0 & 7 \\ 0 & 3 & 0 & 4 & 5 \\ 2 & 0 & 4 & 0 & 0 \\ 0 & 7 & 5 & 0 & 0 \end{bmatrix}

可以发现,这个矩阵是关于主对角线对称的。

以上矩阵中的无穷,其实是可以替换为其他能够代表这两个节点没有边的数值,假如题目规定边的权值是小于1000的,就可以使用1005等数值来代表此处没有边,在第二个矩阵中,我使用了0表示此处没有边,其实在实际代码中,还是设置一个较大值比较方便判断。

2、邻接矩阵实现(C++)

首先,邻接矩阵的空间复杂度是:

O(n2)O(n^2)

假如有n个节点,那么就需要开 n * n 大小的矩阵,因此就是n平方。

其次,邻接矩阵的写法适合稠密图或者无向图,也就是边很多(稠密)的图(其中无向图因为是双向的所以边的数量会乘以2倍),否则会浪费大量空间。我们回头再看上面那个例子,5个节点的图需要开 5 * 5 = 25 大小的矩阵, 总共能记录25条边,然而上面的例子中只有6条有向边,实际上浪费了很多空间,下一个部分再介绍邻接表的写法,邻接表的写法是适合稀疏图的。

最后,上代码:

#include<iostream>
#include<vector>

using namespace std;


const int INF = 0x3f3f3f3f;//用16进制表示的int很大的值,常用于图的初始化

class Graph{
private:
    vector<vector<int>> matrix;
    int stage;//矩阵的阶数,其实就是顶点数量vertex
    bool isUndirected;//默认是有向图

public:
    Graph(int n, bool iU = false):
    matrix(n, vector<int>(n, INF)), stage(n), isUndirected(iU) {}

    void addEdge(int start, int end, int value = 1){
        if(start < 1 || start > stage || end < 1 || end > stage 
        || start == end){
            cout << "输入数据非法!" << endl;
            return;
        }
        matrix[start - 1][end - 1] = value;
        if(isUndirected)
            matrix[end - 1][start- 1] = value;//此处是无向图才需要添加
        //不传入value的值默认为1,代表边无权值的图
    }

    void deleteEdge(int start, int end){
        if(start < 1 || start > stage || end < 1 || end > stage 
        || start == end){
            cout << "输入数据非法!" << endl;
            return;
        }
        matrix[start - 1][end - 1] = INF;
        if(isUndirected)
            matrix[end - 1][start- 1] = INF;//此处是无向图才需要添加
    }

    void printGraph(){
        for(int i = 0; i < stage; i++){
            for(int j = 0; j < stage; j++){
                if(matrix[i][j] == INF) cout << "INF\t";
                else cout << matrix[i][j] << "\t";
            }
            cout << endl;
        }
    }

    int getSize(){
        return stage;
    }

    bool hasEdge(int start, int end){
        if(matrix[start - 1][end - 1] != INF) return true;
        return false;
    }

    int getEdgeValue(int start, int end){
        if(hasEdge(start, end)) return [start - 1][end - 1];
        cout << "这两个节点之间没有边。" << endl;
        return INF;
    }
};

3、邻接表

先举例有向图,还是这个有向图:

graph LR
A((V1)) --->|5| B((V2))
A((V1)) --->|2| D((V4))
B((V2)) --->|7| E((V5))
D((V4)) --->|4| C((V3))
C((V3)) --->|3| B((V2))
C((V3)) --->|5| E((V5))

我们来写出它的邻接表:

graph LR
    A[V1] --->|5| A1[V2] --->|2| A2[V4]
    B[V2] --->|7| B1[V5]
    C[V3] --->|3| C1[V2] --->|5| C2[V5]
    D[V4] --->|4| D1[V3]
    E[V5]

在这里,从上往下V1~V5,我们将这部分看作一个数组,这个数组的下标表示起始节点,数组存储的值的数据类型是我们定义的边结构体,其中包括边的权重和边的目标节点。

4、邻接表实现(C++)

可以使用二维数组实现,也可以使用链表,这里写一个二维数组的实现方式。

#include<iostream>
#include<vector>

using namespace std;

struct Edge{
    int value;
    int to;
};

class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;

public:
    Graph(int n):
    adjList(n), vertex(n){}

    void addEdge(int start, int end, int value){
        if(start < 1 || start > vertex || end < 1 || end > vertex 
        || start == end){
            cout << "输入数据非法。" << endl;
            return;
        }
        adjList[start - 1].push_back({value, end});
    }

    void deleteEdge(int start, int end){
        if(start < 1 || start > vertex || end < 1 || end > vertex 
        || start == end){
            cout << "输入数据非法。" << endl;
            return;
        }
        auto it = adjList[start - 1].find(end);
        if(it != adjList[start - 1].end())
            adjList[start - 1].erase(it);
    }
};

三、图的遍历方式

不重不漏地遍历所有节点

例子:

graph LR
A((V1)) --->|5| B((V2))
A((V1)) --->|2| D((V4))
B((V2)) --->|7| E((V5))
D((V4)) --->|4| C((V3))
C((V3)) --->|3| B((V2))
C((V3)) --->|5| E((V5))

1、深度优先搜索(DFS)

先给出深度优先的遍历结果,假如我们从V1开始遍历:V1 → V2 → V5 → V4 → V3(其实是不唯一的)

根据这次遍历结果不难发现,V1 → V2 → V5,深度在逐渐增加,然而V5没有下一个节点,便回头到V2,V2也没有除了V5以外的其他节点,便回到V1,V1有除了V2以外的节点V4,便遍历V4,然后是V3,到达V3后,所有节点被遍历完了,结束遍历。

假设V1先遍历V4呢?遍历结果可以是:V1 → V4 → V3 → V2 → V5 或者 V1 → V4 → V3 → V5 → V2

道理也是一样的,先往深处找,如果没有就回头查看其他路径,直到所有节点都被遍历。

假如图采用的邻接矩阵的方法,可以得到DFS的C++代码,这里使用的递归方式,实际上也可以直接使用STL的栈(stack):

class Graph{
private:
    vector<vector<int>> matrix;
    int stage;
    bool isUndirected;

    void DFSUtil(int node, vector<bool>& visited) {
        visited[node] = true;
        cout << node;

        for (int i = 0; i < stage; ++i) {
            if (matrix[node - 1][i] != INF && !visited[i + 1]) {
                cout << " --> ";
                DFSUtil(i + 1, visited);
            }
        }
    }

public:
    //省略其他代码...

    void DFS(int start = 1) {
        if (start < 1 || start > stage) {
            cout << "起始节点不存在。" << endl;
            return;
        }

        vector<bool> visited(stage + 1, false);

        // 先从 start 开始遍历
        cout << "从起始节点 " << start << " 开始遍历:" << endl;
        DFSUtil(start, visited);
        cout << endl;

        // 遍历其他连通块
        for (int i = 1; i <= stage; ++i) {
            if (!visited[i]) {
                cout << "从其他连通块的节点 " << i << " 开始遍历:" << endl;
                DFSUtil(i, visited);
                cout << endl;
            }
        }

        cout << "所有节点遍历完成。" << endl;
    }
};

首先我们在public的部分定义了DFS,并且允许传入自由选择的起始遍历节点,接下来分析DFS的函数体:

  1. 判断用户输入的start节点是否存在,如果小于1或者大于最大节点编号stage,提示错误。

  2. visited数组用于记录遍历了哪些节点,初始化所有节点都没有被遍历,即false。

  3. 然后传入递归函数DFSUtil。

  4. 如果这不是一个连通图,visited中就会还有未遍历的节点,我们再遍历一遍visited数组,找到未遍历的节点,继续递归,这样就能保证所有节点都被成功遍历。

然后分析DFSUtil函数,参数是节点node和visited数组,函数体:

  1. 将传入的节点的visited变为true,表示该节点已经被遍历。

  2. 输出该节点,因为这个节点被成功遍历了。

  3. 查找这个节点的边,如果有边,即matrix[node - 1][i] != INF(边值不等于无穷大),那么就递归边指向的节点,即递归 i + 1,因为是这里是下标,所以要 + 1 变成节点的值,继续传入visited数组。

邻接表的实现方式也差不多道理。

2、广度优先搜索(BFS)

还是先看结论,假如从V1开始遍历:V1 → V2 → V4 → V5 → V3(结果也不唯一)

V1之后直接遍历了所有V1相邻的节点(V2和V4),而不是:先走其中一个,等到走完其中一条路再走另一条。

很容易理解,但是有一个细节,V2和V4的位置是可以交换的,如果交换了位置,后面对应的V5和V3也会跟着交换:V1 → V4 → V2 → V3 → V5,因为这里我们先遍历了V4,就应该先遍历V4的边,V4的边连着V3,所以先写V3,再写V2连着的V5。

上面DFS用的邻接矩阵,这里写采用邻接表的BFS实现方式,我这里直接使用STL的队列(queue):

struct Edge{
    int value;
    int to;
};

class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;

    void _BFS(int startNode, vector<bool>& visited) {
        queue<int> q;
        visited[startNode] = true;
        q.push(startNode);
        bool first = true;

        while(!q.empty()){
            int node = q.front();
            q.pop();
            if(first){
                cout << node;
                first = false;
            }
            else{
                cout << " → " << node;
            }

            for(auto& edge: adjList[node - 1]){
                int neighbor = edge.to;
                if(!visited[neighbor]){
                    q.push(neighbor);
                    visited[neighbor] = true;
                }
            }
        }
    }

public:
    //省略其他代码...

    void BFS(int start = 1) {
        if (start < 1 || start > vertex) {
            cout << "起始节点不存在。" << endl;
            return;
        }

        vector<bool> visited(vertex + 1, false);

        // 先从 start 节点开始遍历
        cout << "从起始节点 " << start << " 开始 BFS:" << endl;
        _BFS(start, visited);
        cout << endl;

        // 遍历未访问的其他连通块
        for (int i = 1; i <= vertex; ++i) {
            if (!visited[i]) {
                cout << "从其他连通块的节点 " << i << " 开始 BFS:" << endl;
                _BFS(i, visited);
                cout << endl;
            }
        }

        cout << "所有节点 BFS 完成。" << endl;
    }
};

先解释BFS,也是传入指定的开头节点,不传入就默认为1,函数体:

  1. 判断传入的节点是否合理。

  2. visited数组记录被遍历的节点。

  3. 遍历start节点。

  4. 遍历其他连通块。

然后是 _BFS,传入开头节点和visited数组,函数体:

  1. 头节点标为true,加入队列。

  2. first用来标志是否是第一个节点,后面循环中,如果是第一个节点就不输出" → ",剩下的节点都会输出:" → " + 当前节点值。

  3. 进入循环,获取队首元素,判断后输出,弹出队首元素,查找队首元素的其他边指向的节点,找到没有遍历的节点加入队伍。

这样根据队列一层一层加入就会一层一层输出了。

说实话,我这里的adjList处理得没有那么好,建议在初始化时直接开成n + 1,然后从1开始添加节点,这样就不用那么麻烦地去处理角标和点的关系了,每次 +1 , - 1容易让人出错。

四、最小生成树

1、定义

在一个图中,通过最短的边访问所有节点,这样的边和节点组成的树就是最小生成树。

graph LR
B((V2)) ---|5| D((V4))
B((V2)) ---|1| A((V1))
A((V1)) ---|5| C((V3))
B((V2)) ---|6| C((V3))
A((V1)) ---|4| D((V4))
A((V1)) ---|6| E((V5))
C((V3)) ---|3| E((V5))
D((V4)) ---|2| F((V6))
A((V1)) ---|4| F((V6))
E((V5)) ---|6| F((V6))

例如这个无向图,我们将通过下面两种算法来找其最小生成树。

2、Prim算法

也称加点法,初始最小生成树中为空,给定初始点,向树中添加初始点,然后查找初始点的相邻点,找到距离最近的节点添加到树中,然后查找初始点和新添加的点的整体的相邻点,找到距离最近的添加到树中,重复操作直至访问到所有点。

上述无向图,假如初始点为V2,V2添加至树中:

  1. V2相邻点中距离最近的点为V1,距离为1,加入树中。

  2. V2和V1整体,距离最近的点有V4和V6,距离为4,选择其中一个添加到树中,这里添加V4。

  3. V2、V1、V4整体,距离最近的为V6,距离为2,加入树中。

  4. V2、V1、V4、V6整体,距离最近的为V3,距离为5,加入树中。

  5. V2、V1、V4、V6、V3整体,距离最近的为V5,距离为3,加入树中。

则可得:

graph LR
B((V2)) ---|1| A((V1))
A((V1)) ---|5| C((V3))
A((V1)) ---|4| D((V4))
C((V3)) ---|3| E((V5))
D((V4)) ---|2| F((V6))

这就是通过Prim算法得到的最小生成树。

如果在上面的第2步中选择添加V6,则会得到另一个最小生成树:

graph LR
B((V2)) ---|1| A((V1))
A((V1)) ---|5| C((V3))
A((V1)) ---|4| F((V6))
C((V3)) ---|3| E((V5))
F((V6)) ---|2| D((V4))

因此最小生成树可能不唯一。

邻接矩阵

用邻接矩阵存储的图,使用Prim算法获取最小生成树的C++代码:

class Graph{
private:
    vector<vector<int>> matrix;
    int stage;//矩阵的阶数,其实就是顶点数量vertex
    bool isUndirected;

public:
    //省略其他代码...
    vector<vector<int>> PrimMST() {
        vector<vector<int>> MST(stage, vector<int>(stage, INF));
        vector<bool> isVisited(stage, false);
        isVisited[0] = true;

        for (int k = 1; k < stage; ++k) {
            int s = -1, e = -1, len = INF;
            for (int i = 0; i < stage; ++i) {
                if (isVisited[i]) {
                    for (int j = 0; j < stage; ++j) {
                        if (!isVisited[j] && matrix[i][j] < len) {
                            len = matrix[i][j];
                            s = i;
                            e = j;
                        }
                    }
                }
            }
            if (s == -1 || e == -1) return vector(0, vector<int>(0));
            //图不连通
            MST[s][e] = len;
            if (isUndirected)
                MST[e][s] = len;
            isVisited[e] = true;
        }

        cout << "最小生成树为:" << endl;
        for (int i = 0; i < stage; ++i) {
            for (int j = 0; j < stage; ++j) {
                if (MST[i][j] == INF) cout << "∞";
                else cout << MST[i][j];
                if (j < stage - 1) cout << ' ';
            }
            cout << endl;
        }

        return MST;
    }
};

对于PrimMST函数,做以下解释:

  1. 首先定义最小生成树的邻接矩阵MST,以及记录访问节点的数组isVisited,同时此数组可以标志MST已经有哪些节点,方便查找树整体的相邻节点,初始节点设置为已访问。

  2. 因为已经加入了第一个节点,所以我们只需访问剩下的节点,即stage - 1次,所以外层循环k从1~stage,左闭右开。

  3. 定义s、e和len,分别代表边从s指向e,长度为len,初始长度无限大。

  4. i,j循环,遍历原图,其中已访问的节点,就可以找到已访问节点的相邻节点,选择长度最短的未访问节点记录在s、e和len中。

  5. 如果s、e没被成功赋值,则说明图不连通,没有适合的边,直接退出函数,返回一个空的二维数组。如果赋值成功,更新MST中的值,如果是无向图,两个边都更新,标记e节点,就是新添加的节点为已访问。

  6. 外层循环结束,打印MST结果,然后返回MST的二维数组。

时间复杂度, 取决于定点数V:

O(V2)O(V^2)

邻接表

采用邻接表的图,使用Prim算法,获取最小生成树的时间复杂度会比较低:

struct Edge{
    int weight;
    int to;
    Edge(int w, int t):weight(w), to(t){}
};

Class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;
    bool isUndirected;

public:
    Graph(int v, bool iU = false):vertex(v), isUndirected(iU){
        adjList.resize(vertex);
    }

    void PrimMST(int start = 0){
        vector<bool> isVisited(vertex, false);
        vector<int> minWeight(vertex, INF);
        vector<int> MST(vertex, -1);
        using PII pair<int, int>;

        priority_queue<PII, vector<PII>, greater<>> pq;
        minWeight[start] = 0;
        pq.emplace(0, start);

        while(!pq.empty()){
            int u = pq.top().second;
            pq.pop();
            if(isVisited[u]) continue;
            isVisited[u] = true;

            for(const Edge& edge : adjList[u]){
                int v = edge.to, w = edge.weight;
                if(!isVisited[v] && w < minWeight[v]){
                    minWeight[v] = w;
                    parent[v] = u;
                    pq.emplace(w, v);
                }
            }
        }

        cout << "最小生成树的结构为:" << endl;
        int totalWeight = 0;
        for(size_t i = 0; i < vertex; i++){
            if(parent[i] != -1){
                cout << MST[i] << " ---|"
                     << minWeight[i] << '| '
                     << i << endl;
                totalWeight += minWeight[i];
            }
        }
        cout << "最小生成树的总权重:" << totalWeight << endl;
    }
};

对PrimMST函数的解释:

  1. 开始节点是可以选择的,默认为0开始,isVisited:某个节点是否被访问,初始化全部为否。

  2. minWeight:到达节点v的最短边的长度w,初始化所有边长度为无穷INF;MST:到达节点v的出发节点u,初始化所有节点的出发节点为-1;两个数组的索引:目的节点v;

  3. pq:优先队列,先进先出,底层容器(vector),存储元素的数据结构:pair<int, int>,排序逻辑:从字典序小到大(greater<>),实际上就是存储Edge(weight, to),不过是排序存储,weight最小的会排到队伍的首部。

  4. 从start开始,到达节点start的边长度为0,即minWeight[start] = 0,放入队列中,pq.emplace(0, start)。

  5. 循环(结束条件队列pq为空),第一次进入:u = 队首元素的第二项,即u = (0, start)中的start,弹出队首元素(已经被使用了,所以弹出),如果u节点没有被访问,标记为已访问,开始内层循环:查找u的所有边edge,如果edge指向的节点没有被访问,并且edge的长度比记录的边要短,更新MST和minWeight,将该w,v组合添加到队列。由于该队列是优先队列,队首元素始终是u的所有边中最短的那个,即使最短边是后加入的。

  6. 之后的每次循环:u = 队首元素,之前循环中最短的边的第二项,弹出该最短边,其他地方同理。重点是,对于任意一个节点v,即使在之前我们向队列中和数组中添加了较长的边,被访问标记是没有变为true的,所以在之后的循环如果找到的更短边,也会加入队列,并且,更短边在队列中的位置会比之前的边靠前。最终会先访问到更短边并且更新访问标记为true,之后的长边就会因为节点已访问而被无视直接弹出。

时间复杂度:

O(ElogV)O(ElogV)

3、Kruskal算法

也称加边法,初始最小生成树也为空,所有点为孤立点,每次向树中添加一个原图中最小的边,但是此最小的边的两个端点不能在同一个连通分量内,若不在,则添加此边,若不在,则跳过此边,结束条件依旧是所有的点均被访问。

graph LR
B((V2)) ---|5| D((V4))
B((V2)) ---|1| A((V1))
A((V1)) ---|5| C((V3))
B((V2)) ---|6| C((V3))
A((V1)) ---|4| D((V4))
A((V1)) ---|6| E((V5))
C((V3)) ---|3| E((V5))
D((V4)) ---|2| F((V6))
A((V1)) ---|4| F((V6))
E((V5)) ---|6| F((V6))

针对上述无向图,现在所有点为孤立点(任意两点之间没有边),有:

  1. 原图中V2与V1之间的边最短,为1,并且V2与V1未连通,添加此边。

  2. V4与V6之间边最短,为2,并且这两点未连通,添加此边。

  3. V3与V5之间边最短,为3,并且这两点未连通,添加此边。

  4. V1与V4,或者V1与V6之间边最短,为4,并且这两点未连通,任选一个添加(这里便出现两种情况),此处添加V1与V4。

  5. V1与V6之间边最短,为4,但是这两点连通,V1→ V4 → V6,因此不添加,跳过。

  6. V1与V3之间边最短,为5,这两点未连通,添加此边。

  7. 至此所有点均连通,结束算法。

由第4步可以看出,和Prim算法一样,出现了多种情况,所以,也可以得到两个最小生成树。

邻接表

Kruskal算法更适合稀疏图,邻接矩阵往往用来存储稠密图,如果在邻接矩阵中使用该算法,往往时间复杂度很大,所以这里只介绍邻接表的算法。

采用邻接表存储的图,使用Kruskal算法的C++代码:

struct Edge{
    int weight;
    int from;
    int to;
    Edge(int f, int t, int w) : from(f), to(t), weight(w) {}

    bool operator<(const Edge &b)const{
        return weight < b.weight;
    }
};

Class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;
    bool isUndirected;

    int KruskalFind(vector<int> &parent, int vertex){
        if(parent[vertex] == vertex)
            return vertex;
        else
            return parent[vertex] = KruskalFind(parent, parent[vertex]);
    }

    void KruskalUnit(vector<int> &parent, int vertex_a, int vertex_b){
        int fa = KruskalFind(parent, vertex_a);
        int fb = KruskalFind(parent, vertex_b));

        if(fa != fb)
            parent[fa] = fb;
    }

public:
    Graph(int v, bool iU = false):vertex(v), isUndirected(iU){
        adjList.resize(vertex);
    }

    void KruskalMST(){
        vector<vector<Edge>> MST(vertex);
        int parent[vertex];
        for(int i = 0; i < vertex; i++){
            parent[i] = i;
        }

        vector<Edge> asc_adjList;
        for(size_t i = 0; i < vertex; i++){
            for(auto edge : adjList[i]){
                if(isUndirected){
                    if(edge.from < edge.to)
                        asc_adjList.push_back(edge);
                }
                else
                    asc_adjList.push_back(edge);
            }
        }

        sort(asc_adjList.begin(), asc_adjList.end());

        for(auto edge : asc_adjList){
            if(KruskalFind(parent, edge.from) == KruskalFind(parent, edge.to))
                continue;

            MST[edge.from].push_back(edge);
            KruskalFind(parent, edge.from, edge.to);
        }

        cout << "最小生成树为:" << endl;
        for(size_t i = 0; i < vertex; i++){
            for(auto edge : MST[i]){
                cout << edge.from << "---" << edge.to << endl;
            }
        }
    }
};

现在可以来解释一下该部分代码:

  1. 为结构体添加一个from,用来存储边的起始节点。另外重载小于运算符,让sort函数能够对asc_adjList中的边进行大小排序。

  2. parent数组、KruskalFind函数和KruskalUnit函数,这三个部分是并查集的知识,可以用于快速判断两个节点是否位于同一个树中,也可以快速连接两个节点到同一个树。代码中我们通过这种方式判断两个节点是否在同一个连通块,如果不是,则添加这个最小边,如果是,则跳过这个最小边。

五、Dijkstra最短路径算法

Dijkstra算法用于计算某一点到其他所有点的最短路径的距离,Dijkstra 算法不能处理负权边。

graph LR
    A((a)) ---> |2| B((b))
    B((b)) ---> |1| C((c))
    A((a)) ---> |5| C((c))
    B((b)) ---> |3| D((d))
    C((c)) ---> |3| D((d))
    D((d)) ---> |1| E((e))
    C((c)) ---> |4| E((e))
    D((d)) ---> |4| F((f))
    C((c)) ---> |1| F((f))
    E((e)) ---> |1| F((f))

给定一个起始点,此处起始点假设为a,定义数组visited表示某节点被访问,初始化a节点被访问,其它所有节点未被访问,定义数组dist表示a节点到其他节点的距离,初始化a到自己为0,到其他为无穷,有:

  1. 遍历a的相邻节点,更新相邻节点的dist数组,a --->|2| b,a --->|5| c,可以发现b的距离为2,比原dist中无穷小,更新值为2,c的距离为3,比原dist中的无穷小,更新值为5,此时dist:{ 0, 2, 5, INF, INF, INF }。

  2. 选择相邻节点中最近的那一个,此处为b,标记b为已访问,因为此边为从a到b的直达且最短边,所以不可能有a从其他路径到b还更近,所以b被标记为已访问。

  3. 将已访问的所有点视为整体,查找已访问的节点的相邻节点,b --->|1| c,b --->|3| d, a --->|5| c,可以发现a ---> b ---> c更近,值为2 + 1 = 3,比原dist中的5要小,更新,同理,更新d节点的值,此时dist:{ 0, 2, 3, 5, INF, INF}。

  4. 同理,因为ab整体中有最短边且直达c,所以标记c为已访问。

  5. 重复更新操作和标记操作直至结束,这样就完成了该算法的计算。

通过邻接表存储的图的Dijkstra算法的C++实现:

struct Edge{
    int weight;
    int to;
    Edge(int w, int t):weight(w), to(t){}
};

class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;
    bool isUndirected;

public:
    Graph(int v, bool iU = false):vertex(v), isUndirected(iU){
        adjList.resize(vertex);
    }

    vector<int> Dijkstra(int start){
        vector<bool> visited(vertex, false);
        vector<int> dist(vertex, INF);

        priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
        pq.emplace(0, start);
        dist[start] = 0;

        while(!pq.empty()){
            int u = pq.top().second;
            pq.pop();

            if (visited[u]) continue;
            visited[u] = true;

            for(auto edge : adjList[u]){
                int v = edge.to;
                int w = edge.weight;

                if(!visited[v] && dist[v] > dist[u] + w){
                    dist[v] = dist[u] + w;
                    pq.emplace(dist[v], v);
                }
            }
        }
        return dist;
    }
};

大功告成。

六、Floyd最短路径算法

Floyd算法用于求任意两点之间的路径大小,相较于Dijkstra,Floyd可以处理负数权值的边。

graph TD
    A((0)) ---> |6| B((1))
    B((1)) ---> |10| A((0))
    A((0)) ---> |13| C((2))
    C((2)) ---> |5| A((0))
    B((1)) ---> |4| C((2))

如图三个节点,要求任意两点之间的最短路径长度,维护一个二维数组.

由图可以得到第一个表D(-1),表示在未引入任何中间点之前的初始距离表,直接使用边权作为初始路径。

例如:第1行,第0列,表示节点1到节点0的路径长度为10:

| D(-1) | 0 | 1 | 2 | |:-----:|:---:|:---:|:---:| | 0 | 0 | 6 | 13 | | 1 | 10 | 0 | 4 | | 2 | 5 | INF | 0 |

接下来,我们将所有点作为中间点来更新表格,依照的对象是上一个表格。

对角线是不用更新的,因为自己到自己永远是0。

当0为中间点时,第0行第0列是不用更新的,因为此时的0同时也是目标点或者起始点,既然是目标点和起始点,那也就没有中间点,所以无需更新。

所以我们需要更新的位置只有1 → 0 → 22 → 0 → 1下划线部分是可能需要更新的位置,额外高亮的部分是实际更新的点。

1 → 0 → 2,显而易见的是,从1到0再到2的路径长度为:10 + 13 = 26,大于原路径长度4,所以无需更新。

2 → 0 → 1,从2到0再到1的路径长度为:5 + 6 = 11,小于原路径长度无穷,所以更新。

| D(0) | 0 | 1 | 2 | |:----:|:---:|:------------------------:|:----------:| | 0 | 0 | 6 | 13 | | 1 | 10 | 0 | 4 | | 2 | 5 | 11 | 0 |

以1为中间点时依照上一个表格,需要更新的位置只有0 → 1 → 22 → 1 → 0

0 → 1 → 2,路径长度为:6 + 4 = 10,小于原路径长度13,更新。

2 → 1 → 0,路径长度为:11 + 10 = 21,大于原路径长度5,不更新。

| D(1) | 0 | 1 | 2 | |:----:|:----------:|:---:|:------------------------:| | 0 | 0 | 6 | 10 | | 1 | 10 | 0 | 4 | | 2 | 5 | 11 | 0 |

以2为中间点时依照上一个表格,需要更新的位置只有0 → 2 → 11 → 2 → 0

0 → 2 → 1,路径长度为:10 + 11 = 21,大于原路径长度6,不更新。

1 → 2 → 0,路径长度为:4 + 5 = 9,小于原路径长度10,更新。

| D(2) | 0 | 1 | 2 | |:----:|:-----------------------:|:----------:|:---:| | 0 | 0 | 6 | 10 | | 1 | 9 | 0 | 4 | | 2 | 5 | 11 | 0 |

所以我们就得到了最终的表格D(2),这里面记录了任意两点的最短路径。

graph TD
    A((0)) ---> |6| B((1))
    B((1)) ---> |10| A((0))
    A((0)) ---> |13| C((2))
    C((2)) ---> |5| A((0))
    B((1)) ---> |4| C((2))

除此之外,Floyd还可以记录最短路径的过程,也就是从哪里开始,经过哪些点,到达哪个目的点。

使用一个路径表,路径表记录的是从起点到终点的最短路径中,终点之前的那个节点是谁,用来递归构造完整路径,根据原图进行初始化,可得:

| P(-1) | 0 | 1 | 2 | |:-----:|:---:|:---:|:---:| | 0 | -1 | 0 | 0 | | 1 | 1 | -1 | 1 | | 2 | 2 | -1 | -1 |

例如,第1行第0列表示,从节点1到节点0,最后一个节点是0,上一个节点是1。

同样是以每一个点为中间点,根据上一个表格更新得到下一个表格。

同理,对应行列不用更新,我以下划线标注此表可能需要更新的部分,额外高亮部分是实际更新的部分。

以0为中间点。

1 → 0 → 2,显而易见的是,从1到0再到2的路径长度为:10 + 13 = 26,大于原路径长度4,所以无需更新,那么,路径的过程也不用更新。

2 → 0 → 1,从2到0再到1的路径长度为:5 + 6 = 11,小于原路径长度无穷,所以更新,因此路径过程中,原路径2 → 1变为2 → 0 → 1,则最后一个节点1的上一个节点为0,表格值更新为0。

| P(0) | 0 | 1 | 2 | |:----:|:---:|:-----------------------:|:----------:| | 0 | -1 | 0 | 0 | | 1 | 1 | -1 | 1 | | 2 | 2 | 0 | -1 |

以1为中间点。

0 → 1 → 2,路径长度为:6 + 4 = 10,小于原路径长度13,更新,路径改变,更新为1。

2 → 1 → 0,路径长度为:11 + 10 = 21,大于原路径长度5,不更新。

| P(1) | 0 | 1 | 2 | |:----:|:----------:|:---:|:-----------------------:| | 0 | -1 | 0 | 1 | | 1 | 1 | -1 | 1 | | 2 | 2 | 0 | -1 |

以2为中间点。

0 → 2 → 1,路径长度为:10 + 11 = 21,大于原路径长度6,不更新。

1 → 2 → 0,路径长度为:4 + 5 = 9,小于原路径长度10,更新。

| P(2) | 0 | 1 | 2 | |:----:|:-----------------------:|:----------:|:---:| | 0 | -1 | 0 | 1 | | 1 | 2 | -1 | 1 | | 2 | 2 | 0 | -1 |

至此,可以查找任意两点的最短路径的过程。

比如,从1到0的最短路径,先找到第1行第0列,最后一个节点是0,前一个节点为2,那么就是0 ← 2, 然后再查找1到2的最短路径,找到第1行第2列,发现结果是1,则说明2的前一个节点是1,那么就有0 ← 2 ← 1,倒过来就是路径的过程了,所以最终路径为:1 → 2 → 0。

由于Floyd算法本来就使用了二维数组来存储,那么邻接矩阵再适合不过了:

class Graph{
private:
    vector<vector<int>> matrix;
    int stage;//矩阵的阶数,其实就是顶点数量vertex
    bool isUndirected;

public:
    //省略其他部分代码...
    void Floyd(){
        vector<vector<int>> dist(matrix);
        vector<vector<int>> path(n, vector<int>(n, -1));

        for(int i = 0; i < stage; i++){
            for(int j = 0; j < stage; j++){
                if(dist[i][i] != INF && i != j){
                    path[i][j] = i;
                }
            }
        }

        for (int k = 0; k < stage; k++) {
            for (int i = 0; i < stage; i++) {
                for (int j = 0; j < stage; j++) {
                    if (dist[i][k] < INF && dist[k][j] < INF &&
                        dist[i][k] + dist[k][j] < dist[i][j]) {
                        dist[i][j] = dist[i][k] + dist[k][j];
                        path[i][j] = path[k][j];
                    }
                }
            }
        }

        cout << "最短距离矩阵:" << endl;
        for (int i = 0; i < stage; i++) {
            for (int j = 0; j < stage; j++) {
                if (dist[i][j] == INF) cout << "INF ";
                else cout << dist[i][j] << " ";
            }
            cout << endl;
        }

        cout << "\n任意两点路径示例 (格式: from -> to: path):" << endl;
        for (int i = 0; i < stage; i++) {
            for (int j = 0; j < stage; j++) {
                if (i != j && dist[i][j] != INF) {
                    cout << i << " -> " << j << ": ";
                    printPath(path, i, j);
                    cout << j << endl;
                }
            }
        }
    }

    void printPath(vector<vector<int>>& path, int u, int v) {
        if (path[u][v] == -1) return;
        printPath(path, u, path[u][v]);
        cout << path[u][v] << " -> ";
    }
};

七、拓扑排序

根据节点的入度决定节点的输出顺序,只输出入度为零的节点。

拓扑排序只能用于没有环的图,如果图中出现了环,环中的节点都肯定有入度:

graph LR
    A((a)) ---> B((b))
    B((b)) ---> C((c))
    C((c)) ---> D((d))
    D((d)) ---> E((e))
    E((e)) ---> A((a))

没有入度为零的节点,则不能进行拓扑排序。

接下来进行一个拓扑排序的例子分析。

例如该图:

graph LR
    A((a)) ---> E((e))
    E((e)) ---> D((d))
    A((a)) ---> C((c))
    C((c)) ---> D((d))
    A((a)) ---> B((b))
    B((b)) ---> C((c))
  1. a节点入度为零,输出a,删除a节点。

  2. b,e节点入度为零,则任选其一,此时代表拓扑排序不唯一,此处输出b,删除b节点。

  3. c,e节点入度为零,任选其一,此处输出c,删除c节点。

  4. e节点入度为零,输出e,删除e节点。

  5. d节点入度为零,输出d,删除d节点,结束。

  6. 最终结果:a → b → c → e → d(不唯一,第2步和第3步可以选择不同的输出)。

邻接表的C++拓扑排序实现:

struct Edge{
    int weight;
    int to;
    Edge(int w, int t):weight(w), to(t){}
};

Class Graph{
private:
    vector<vector<Edge>> adjList;
    int vertex;
    bool isUndirected;

public:
    //省略其他代码...
    void topSort() {
        vector<int> indegree(vertex, 0);

        // 计算所有顶点的入度
        for (int i = 0; i < vertex; i++) {
            for (auto& edge : adjList[i]) {
                indegree[edge.to]++;
            }
        }

        queue<int> q;
        // 将所有入度为0的顶点加入队列
        for (int i = 0; i < vertex; i++) {
            if (indegree[i] == 0) {
                q.push(i);
            }
        }

        bool first = true;
        while (!q.empty()) {
            int u = q.front(); q.pop();

            if (!first) cout << " ---> ";
            first = false;
            cout << u;

            // 遍历其邻接点,并更新入度
            for (auto& edge : adjList[u]) {
                indegree[edge.to]--;
                if (indegree[edge.to] == 0) {
                    q.push(edge.to);
                }
            }
        }

        // 检查是否有环(即还有点入度不为0)
        for (int i = 0; i < vertex; i++) {
            if (indegree[i] != 0) {
                cout << "\n图中存在环,无法拓扑排序" << endl;
                return;
            }
        }
    }
};

八、AOE网和关键路径